package json.gtk;

import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Stack;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.NotThreadSafe;

import org.gnome.gtk.DataColumn;
import org.gnome.gtk.DataColumnString;
import org.gnome.gtk.Gtk;
import org.gnome.gtk.TreeIter;
import org.gnome.gtk.TreePath;
import org.gnome.gtk.TreeStore;
import org.json.simple.parser.ContentHandler;
import org.json.simple.parser.ParseException;

import com.google.common.base.Preconditions;
import com.google.common.collect.Maps;

/**
 * Builds a GTK {@link TreeStore} out of a JSON document. One instance of this
 * class populates one {@link TreeStore} - they are not reusable. Remember to
 * call {@link Gtk#init(String[])} first!
 */
@NotThreadSafe public final class TreeModelBuilder implements ContentHandler {
	@Immutable public static final class Built {
		public final @Nonnull TreeStore store;
		public final @Nonnull Map<String, DataColumnString> columns;

		private Built(TreeStore store, Map<String, DataColumnString> columns) {
			this.store = store;
			this.columns = columns;
		}
	}

	private enum State {
		BEFORE, DURING, AFTER
	};

	private State state = State.BEFORE;
	/**
	 * {@link LinkedHashMap}, so iteration order is insertion order
	 */
	private Map<String, DataColumnString> columns = Maps.newLinkedHashMap();
	private TreeStore store = new TreeStore(new DataColumn[] { new DataColumnString() }); // dummy

	/**
	 * If we want to store a field and this is null, add a new row to the table
	 */
	private TreeIter currentRow = null;
	private String currentColumn = null;
	private boolean seenFirst = false;
	private int autoGeneratedColumnSuffix = 1;
	private final Stack<Integer> autoGeneratedColumnSuffixes = new Stack<Integer>();

	/**
	 * We have to copy the whole table - expensive!
	 */
	private DataColumnString addColumn(String name) {
		// we're adding a column to this row:
		TreePath currentPath = currentRow == null ? null : store.getPath(currentRow);

		Map<String, DataColumnString> newColumns = Maps.newLinkedHashMap();
		for (String key : columns.keySet()) {
			// we can't re-use these
			newColumns.put(key, new DataColumnString());
		}
		DataColumnString type = new DataColumnString();
		newColumns.put(name, type);

		// copy over the data
		TreeStore newStore = new TreeStore(newColumns.values().toArray(new DataColumn[newColumns.size()]));
		TreeIter oldIter = store.getIterFirst();
		TreeIter newIter = null;
		while (oldIter != null) {
			newIter = newStore.appendRow();
			copyRow(newStore, newIter, newColumns, store, oldIter, columns);
			copyChildren(store, oldIter, columns, newStore, newIter, newColumns);

			if (!oldIter.iterNext()) {
				break;
			}
		}

		// update fieldscurrentRow
		store.clear(); // free up some memory
		columns = newColumns;
		if (currentPath != null) {
			currentRow = newStore.getIter(currentPath);
			if (currentRow == null) {
				// this is off the end
				currentRow = newStore.appendRow();
			}
		}
		store = newStore;

		return type;
	}

	/**
	 * if we have children, recursively copy
	 */
	private static void copyChildren(TreeStore oldStore, TreeIter oldIter, Map<String, DataColumnString> oldColumns, TreeStore newStore, TreeIter newIter, Map<String, DataColumnString> newColumns) {
		if (oldStore.iterHasChild(oldIter)) {
			TreeIter oldChild = oldStore.iterChildren(oldIter);
			do {
				TreeIter newChild = newStore.appendChild(newIter);
				copyRow(newStore, newChild, newColumns, oldStore, oldChild, oldColumns);
				copyChildren(oldStore, oldChild, oldColumns, newStore, newChild, newColumns);
			} while (oldChild.iterNext());
		}
	}

	private static void copyRow(TreeStore newStore, TreeIter newIter, Map<String, DataColumnString> newColumns, TreeStore oldStore, TreeIter oldIter, Map<String, DataColumnString> oldColumns) {
		for (String column : oldColumns.keySet()) {
			String oldValue = oldStore.getValue(oldIter, oldColumns.get(column));
			// if this is null, nothing was ever set for this cell - but we
			// can't set nulls in a table, so skip it
			if (oldValue != null) {
				newStore.setValue(newIter, newColumns.get(column), oldValue);
			}
		}
	}

	private DataColumnString getColumn(@Nullable String name) {
		if (name == null) {
			name = "Column" + autoGeneratedColumnSuffix++;
		}
		DataColumnString ret = columns.get(name);
		return ret == null ? addColumn(name) : ret;
	}

	private void newRow() {
		if (currentRow == null) {
			currentRow = store.appendRow();
		} else {
			currentRow = store.appendChild(currentRow);
		}
		autoGeneratedColumnSuffixes.push(autoGeneratedColumnSuffix);
		autoGeneratedColumnSuffix = 1; // in case we get an object for a sub-row
	}

	private void endRow() {
		if (currentRow != null) {
			currentRow = store.iterParent(currentRow); // can be null
		}
		currentColumn = null;
		autoGeneratedColumnSuffix = autoGeneratedColumnSuffixes.isEmpty() ? 1 : autoGeneratedColumnSuffixes.pop();
	}

	public boolean endArray() throws ParseException, IOException {
		endRow();
		return true;
	}

	public void endJSON() throws ParseException, IOException {
		Preconditions.checkArgument(state == State.DURING);
		state = State.AFTER;
	}

	public boolean endObject() throws ParseException, IOException {
		endRow();
		return true;
	}

	public boolean endObjectEntry() throws ParseException, IOException {
		currentColumn = null;
		return true;
	}

	public boolean primitive(Object value) throws ParseException, IOException {
		DataColumnString column = getColumn(currentColumn);
		if (value != null) {
			store.setValue(currentRow, column, value.toString());
		}
		return true;
	}

	public boolean startArray() throws ParseException, IOException {
		if (seenFirst && currentColumn == null) {
			newRow();
		} else {
			seenFirst = true;
		}
		return true;
	}

	public void startJSON() throws ParseException, IOException {
		Preconditions.checkArgument(state == State.BEFORE);
		state = State.DURING;
	}

	public boolean startObject() throws ParseException, IOException {
		newRow();
		return true;
	}

	public boolean startObjectEntry(String property) throws ParseException, IOException {
		currentColumn = property;
		return true;
	}

	public @Nonnull Built build() {
		Preconditions.checkArgument(state == State.AFTER);
		return new Built(store, Collections.unmodifiableMap(columns));
	}
}
